<?php

namespace Cephalopod\SSL;

use Cephalopod\Http;
use Cephalopod\HttpTube;
use Cephalopod\LogRecorder;
use Exception;

class Server implements MainLogic
{
    private string $listenIpAddress = '127.0.0.1';

    private int $listenPort = 9002;

    private string $clientPublicKey = '';

    private string $serverPrivateKey = '';

    private array $idContextMap = []; // Key is id, value is a Context object

    public function __construct()
    {
        IOEvent::bind($this);
    }

    public function setListenIpAddress(string $listenIpAddress)
    {
        $this->listenIpAddress = $listenIpAddress;
    }

    public function setListenPort(int $listenPort)
    {
        $this->listenPort = $listenPort;
    }

    public function setClientPublicKey(string $filePath)
    {
        $this->clientPublicKey = file_get_contents($filePath);
    }

    public function setServerPrivateKey(string $filePath)
    {
        $this->serverPrivateKey = file_get_contents($filePath);
    }

    public function run(): bool
    {
        LogRecorder::logInfo("The server will listen for TCP connections on port {$this->listenPort} with address {$this->listenIpAddress}");
        while (true) {
            try {
                IOEvent::run($this->listenIpAddress, $this->listenPort);
            } catch (Exception $e) {
                LogRecorder::logError('An exception occurred: "' . $e->getMessage() . '"');
                LogRecorder::logInfo('All network connections are lost: total=' . count($this->idContextMap, COUNT_NORMAL));
                $this->idContextMap = [];
            }
        }
        return true;
    }

    public function handleReadEvent(int $id, string &$readBuffer): int
    {
        if (!isset($this->idContextMap[$id])) {
            throw new Exception("ID: $id does not exist, do not read data");
        }
        $context = $this->idContextMap[$id];
        $context->lastDataRecvTime = time();
        if ($context->isClientServerConnection) {
            try {
                $this->checkKeepAliveAndSendHeartBeatPackage($context);
            } catch (Exception $e) {
                LogRecorder::logError('An exception occurred: "' . $e->getMessage() . '"');
                IOEvent::closeExistsConnection($id);
                return 0;
            }
            LogRecorder::logInfo("Download " . strlen($readBuffer) . " bytes from the client, ID: $id");
            $totalDataSizeBefore = strlen($context->dataToBeWritten);
            foreach ($context->unpack($readBuffer, false) as $data) {
                $this->triggerProtocolReadEvent($context, ...$data);
            }
            $totalDataSizeAfter = strlen($context->dataToBeWritten);
            $totalDataSize = $totalDataSizeAfter - $totalDataSizeBefore;
            $totalDataSize > 0 && LogRecorder::logInfo("The data to be uploaded to the client +$totalDataSize bytes. From ID: $id");
        } else {
            LogRecorder::logInfo("Download " . strlen($readBuffer) . " bytes from website, ID: $id");
            $clientServerConnectionId = $this->idContextMap[$id]->dataSentToId;
            if (isset($this->idContextMap[$clientServerConnectionId])) {
                $connectionContext = $this->idContextMap[$clientServerConnectionId];
                if (isset($connectionContext->websiteIdBrowserIdMap[$id])) {
                    $browserId = $connectionContext->websiteIdBrowserIdMap[$id];
                    $totalDataSizeBefore = strlen($connectionContext->dataToBeWritten);
                    $this->splitAndPackRemoteData($browserId, $connectionContext, $readBuffer);
                    $totalDataSizeAfter = strlen($connectionContext->dataToBeWritten);
                    LogRecorder::logInfo("The data to be uploaded to the client +" . ($totalDataSizeAfter - $totalDataSizeBefore) . " bytes. From ID: $id");
                } else {
                    LogRecorder::logError("Browser closed connection, ID: $id will close");
                    IOEvent::closeExistsConnection($id);
                }
            } else {
                LogRecorder::logError("Client connection lost, ID: $id will close");
                IOEvent::closeExistsConnection($id);
            }
        }
        return PHP_INT_MAX;
    }

    public function handleWriteEvent(int $id, string &$writeBuffer)
    {
        if (!isset($this->idContextMap[$id])) {
            LogRecorder::logError("A writable event occurred on an unknown ID: $id. Close connection");
            IOEvent::closeExistsConnection($id);
            return;
        }
        $context = $this->idContextMap[$id];
        try {
            $this->checkKeepAliveAndSendHeartBeatPackage($context);
        } catch (Exception $e) {
            LogRecorder::logError('An exception occurred: "' . $e->getMessage() . '"');
            IOEvent::closeExistsConnection($id);
            return;
        }
        if ('' === $context->dataToBeWritten) {
            return;
        }
        $writeBuffer = $context->dataToBeWritten;
        LogRecorder::logInfo("Send data " . strlen($context->dataToBeWritten) . " bytes to ID: $id");
        $context->dataToBeWritten = '';
    }

    public function handleOpenEvent(int $id)
    {
        if (!isset($this->idContextMap[$id])) {
            LogRecorder::logInfo("A new client connection is established, ID: $id");
            $context = new Context($this->serverPrivateKey);
            $context->isClientServerConnection = true;
            $context->contextBelongsToId = $id;
            $context->lastDataRecvTime = time();
            $this->idContextMap[$id] = $context;
            return;
        }
        if ($this->idContextMap[$id]->isClientServerConnection) {
            throw new Exception("Duplicate notification. ID: $id");
        }
        LogRecorder::logInfo("Successful connection to a third-party website, ID: $id");
    }

    public function handleCloseEvent(int $id)
    {
        if (!isset($this->idContextMap[$id])) {
            throw new Exception("ID: $id has been destroyed. Duplicate notification");
        }
        unset($this->idContextMap[$id]);
    }

    private function triggerProtocolReadEvent(Context $context, int $browserId, int $controlFlag, string $plaintext)
    {
        switch ($controlFlag) {
            case Protocol::CONTROL_FLAG_HEARTBEAT:
                LogRecorder::logInfo('Heartbeat packet received, ID: ' . $context->contextBelongsToId);
                $context->lastHeartBeatRecvTime = time();
                break;
            case Protocol::CONTROL_FLAG_FIRST_DATA_NO:
            case Protocol::CONTROL_FLAG_FIRST_DATA_YES:
                if (!isset($context->browserIdInfoMap[$browserId])) {
                    $context->browserIdInfoMap[$browserId] = [
                        'is_http' => true,
                        'unknow_data' => '',
                        'send_to_id' => 0,
                    ];
                }
                $info = &$context->browserIdInfoMap[$browserId];
                $info['unknow_data'] .= $plaintext;
                if ($info['is_http']) {
                    if (strlen($info['unknow_data']) < 7) {
                        break;
                    }
                    if (false === strpos($info['unknow_data'], Http::HTTP_EOL . Http::HTTP_EOL)) {
                        break;
                    }
                    list($firstLine, $headers, $body, $unknowData) = Http::separate($info['unknow_data']);
                    $hostInfo = Http::parse($firstLine . Http::HTTP_EOL);
                    if (empty($hostInfo['host']) || empty($hostInfo['port'])) {
                        LogRecorder::logError('Data that can not be parsed');
                        $response = "HTTP/1.1 500 Internal Server Error\r\nContent-Type: text/plain\r\nContent-Length: 0\r\n\r\n";
                        $context->dataToBeWritten .= HttpTube::buildResponseData(
                            Protocol::pack($browserId, Protocol::CONTROL_FLAG_FIRST_DATA_YES, $response, fn(string $plaintext) => OpenSSL::encrypt($plaintext, $this->clientPublicKey))
                        );
                        break;
                    }
                    if (0 === strpos($info['unknow_data'], 'CONNECT')) {
                        $info['is_http'] = false;
                        $websiteId = IOEvent::createNewConnection($hostInfo['host'], $hostInfo['port']);
                        LogRecorder::logInfo("ID: {$context->contextBelongsToId} will connect to {$hostInfo['host']}:{$hostInfo['port']} (ID: $websiteId)");
                        $response = "HTTP/1.1 200 Connection Established\r\n\r\n";
                        $context->dataToBeWritten .= HttpTube::buildResponseData(
                            Protocol::pack($browserId, Protocol::CONTROL_FLAG_FIRST_DATA_YES, $response, fn(string $plaintext) => OpenSSL::encrypt($plaintext, $this->clientPublicKey))
                        );
                        $info['send_to_id'] = $websiteId;
                        $websiteContext = new Context();
                        $websiteContext->contextBelongsToId = $websiteId;
                        $websiteContext->dataSentToId = $context->contextBelongsToId;
                        $websiteContext->dataToBeWritten .= $unknowData;
                        $this->idContextMap[$websiteId] = $websiteContext;
                        $context->websiteIdBrowserIdMap[$websiteId] = $browserId;
                        $info['unknow_data'] = '';
                    } else {
                        if (0 === $info['send_to_id']) {
                            $websiteId = IOEvent::createNewConnection($hostInfo['host'], $hostInfo['port']);
                            $info['send_to_id'] = $websiteId;
                            $websiteContext = new Context();
                            $websiteContext->contextBelongsToId = $websiteId;
                            $websiteContext->dataSentToId = $context->contextBelongsToId;
                            $this->idContextMap[$websiteId] = $websiteContext;
                            $context->websiteIdBrowserIdMap[$websiteId] = $browserId;
                        } else {
                            $websiteContext = $this->idContextMap[$info['send_to_id']] ?? null;
                            if (is_null($websiteContext)) {
                                LogRecorder::logError("The ID: {$info['send_to_id']} of the corresponding end that the browser is looking for does not exist");
                                break;
                            }
                        }
                        while (true) {
                            $websiteContext->dataToBeWritten .= Http::removeProxy($firstLine, $headers, $body);
                            $info['unknow_data'] = $unknowData;
                            if (strlen($info['unknow_data']) < 7) {
                                break 2;
                            }
                            if (false === strpos($info['unknow_data'], Http::HTTP_EOL . Http::HTTP_EOL)) {
                                break 2;
                            }
                            list($firstLine, $headers, $body, $unknowData) = Http::separate($info['unknow_data']);
                            if (strlen($unknowData) == strlen($info['unknow_data'])) {
                                break 2;
                            }
                        }
                    }
                } else {
                    $websiteId = $info['send_to_id'];
                    if (isset($this->idContextMap[$websiteId])) {
                        $this->idContextMap[$websiteId]->dataToBeWritten .= $info['unknow_data'];
                        $info['unknow_data'] = '';
                    } else {
                        LogRecorder::logError("Unknown ID: $websiteId");
                    }
                }
                break;
            default:;
        }
    }

    private function checkKeepAliveAndSendHeartBeatPackage(Context $context)
    {
        if ($context->isClientServerConnection) {
            $nowTime = time();
            $failTime = Protocol::HEARTBEAT_FAIL;
            if ($nowTime - $context->lastHeartBeatRecvTime > $failTime && $nowTime - $context->lastDataRecvTime > $failTime) {
                throw new Exception("Connection closed due to keep-alive timeout. No data communication has been received for {$failTime} seconds. ID: {$context->contextBelongsToId}");
            }
            if ($nowTime - $context->lastHeartBeatSendTime > Protocol::HEARTBEAT_DELAY) {
                $context->dataToBeWritten .= HttpTube::buildResponseData(
                    Protocol::pack(mt_rand(0, PHP_INT_MAX), Protocol::CONTROL_FLAG_HEARTBEAT, (string)mt_rand(0, PHP_INT_MAX), fn(string $plaintext) => OpenSSL::encrypt($plaintext, $this->clientPublicKey))
                );
                LogRecorder::logInfo('Write heartbeat packet to server connection, ID: ' . $context->contextBelongsToId);
                $context->lastHeartBeatSendTime = $nowTime;
            }
        }
    }

    /**
     * If a huge amount of data is transmitted remotely, the result of packaging
     * will be very large! If the network speed from the client to the server
     * is slow, receiving the packaged results will take a long time, which may
     * cause the browser connection to be interrupted due to timeout.
     *
     * Therefore, it is appropriate to cut and package remote data according to
     * a certain size.
     *
     * @param int $browserId              The browser ID (connection ID) will be included
     *                                    in the data package when packaging data.
     * @param Context $connectionContext  The connection where the buffer will be written.
     * @param string &$readBuffer         The string of binary data (used by reference),
     *                                    which will be packaged
     * @return void
     */
    private function splitAndPackRemoteData(int $browserId, Context $connectionContext, string &$readBuffer)
    {
        $splitSize = 1024;
        $readBufferLength = strlen($readBuffer);
        if ($readBufferLength <= $splitSize) {
            $this->packRemoteData($browserId, $connectionContext, $readBuffer);
            return;
        }
        $offset = 0;
        do {
            $data = substr($readBuffer, $offset, $splitSize);
            $dataLength = strlen($data);
            $offset += $dataLength;
            $this->packRemoteData($browserId, $connectionContext, $data);
        } while ($offset < $readBufferLength);
    }

    /**
     * Package the data according to the specified parameters and place it in
     * the write buffer of the specified connection.
     *
     * @param int $browserId              The browser ID (connection ID) will be included
     *                                    in the data package when packaging data.
     * @param Context $connectionContext  The connection where the buffer will be written.
     * @param string &$readBuffer         The string of binary data (used by reference),
     *                                    which will be packaged
     * @return void
     */
    private function packRemoteData(int $browserId, Context $connectionContext, string &$readBuffer)
    {
        $connectionContext->dataToBeWritten .= HttpTube::buildResponseData(
            Protocol::pack($browserId, Protocol::CONTROL_FLAG_FIRST_DATA_YES, $readBuffer, fn(string $plaintext) => OpenSSL::encrypt($plaintext, $this->clientPublicKey))
        );
    }
}
